Skip to content

Conversation

@thevilledev
Copy link
Contributor

Add explicit stack length checks in pop and current VM helpers. Extend VM tests to assert safe underflow error reporting.

The test fails against master as follows:

=== RUN   TestVM_StackUnderflow/underflow_after_valid_operations
    vm_test.go:1377:
                Error Trace:    /git/expr/vm/vm_test.go:1377
                Error:          "runtime error: index out of range [-1]" does not contain "stack underflow"
                Test:           TestVM_StackUnderflow/underflow_after_valid_operations
=== RUN   TestVM_StackUnderflow/pop_on_empty_stack
    vm_test.go:1377:
                Error Trace:    /git/expr/vm/vm_test.go:1377
                Error:          "runtime error: index out of range [-1]" does not contain "stack underflow"
                Test:           TestVM_StackUnderflow/pop_on_empty_stack

Bench results from go test -run=^$ -bench=. -count=10 . below. Geomean at roughly +2%.

goos: linux
goarch: amd64
pkg: github.com/expr-lang/expr
cpu: AMD EPYC 9575F 64-Core Processor
                            │   before    │                after                 │
                            │   sec/op    │    sec/op      vs base               │
_expr-32                      47.99n ± 3%    46.27n ±  2%  -3.57% (p=0.000 n=10)
_expr_eval-32                 4.173µ ± 1%    4.059µ ±  1%  -2.74% (p=0.000 n=10)
_expr_reuseVm-32              30.41n ± 5%    31.59n ± 11%       ~ (p=0.052 n=10)
_len-32                       29.98n ± 1%    31.07n ± 27%  +3.64% (p=0.003 n=10)
_filter-32                    34.79µ ± 1%    34.28µ ±  1%  -1.46% (p=0.001 n=10)
_filterLen-32                 29.33µ ± 4%    30.22µ ±  2%  +3.03% (p=0.029 n=10)
_filterFirst-32               318.6n ± 1%    308.3n ±  1%  -3.23% (p=0.000 n=10)
_filterLast-32                549.2n ± 1%    566.4n ±  1%  +3.11% (p=0.000 n=10)
_filterMap-32                 3.764µ ± 1%    3.661µ ±  1%  -2.74% (p=0.000 n=10)
_arrayIndex-32                51.59n ± 9%    52.23n ±  4%       ~ (p=0.631 n=10)
_envStruct-32                 33.23n ± 1%    33.82n ±  4%  +1.81% (p=0.012 n=10)
_envMap-32                    37.17n ± 1%    37.98n ±  4%  +2.18% (p=0.000 n=10)
_callFunc-32                  234.0n ± 1%    253.8n ±  4%  +8.46% (p=0.000 n=10)
_callMethod-32                248.3n ± 1%    252.4n ±  0%  +1.65% (p=0.000 n=10)
_callField-32                 49.24n ± 1%    49.30n ±  2%       ~ (p=0.955 n=10)
_callFast-32                  50.42n ± 5%    50.05n ± 17%       ~ (p=0.780 n=10)
_callConstExpr-32             19.52n ± 3%    21.21n ±  1%  +8.66% (p=0.000 n=10)
_largeStructAccess-32         94.00n ± 1%    97.49n ±  3%  +3.71% (p=0.000 n=10)
_largeNestedStructAccess-32   99.37n ± 1%   102.45n ±  2%  +3.10% (p=0.000 n=10)
_largeNestedArrayAccess-32    460.0µ ± 1%    478.5µ ±  1%  +4.03% (p=0.000 n=10)
_sort-32                      2.636µ ± 2%    2.681µ ±  1%  +1.71% (p=0.019 n=10)
_sortBy-32                    6.575µ ± 1%    6.929µ ±  1%  +5.39% (p=0.000 n=10)
_groupBy-32                   7.182µ ± 1%    7.671µ ±  1%  +6.82% (p=0.000 n=10)
_reduce-32                    3.326µ ± 2%    3.654µ ±  1%  +9.88% (p=0.000 n=10)
geomean                       491.3n         502.9n        +2.35%

Add explicit stack length checks in pop and current helpers.
Extend VM tests to assert safe underflow error reporting.

Signed-off-by: Ville Vesilehto <ville@vesilehto.fi>
@antonmedv
Copy link
Member

But what is the case? Those opcode seq will not be generated? Only manually.

@thevilledev
Copy link
Contributor Author

Yes, the compiler output doesn't generate those sequences. But vm.Program (incl. Bytecode field) and vm.Run are exported, possibly exposing embedders to such scenarios. I think these would make future refactoring or new opcodes also safer to develop, so the invariant is never broken. The performance trade-off is quite modest IMO, but what do you think?

if len(vm.Stack) == 0 {
panic("stack underflow")
}
return vm.Stack[len(vm.Stack)-1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this will also panic (with differnt message obviosly)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's then easier to assert to that consistent error msg (if wanted). Maybe "robust" would have been a better choice than "safer".

Also just realised that this might become handy if the project fuzzer would do bytecode fuzzing as well. Distinguishing stack underflow from other errors.

Copy link
Contributor Author

@thevilledev thevilledev Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a small bytecode fuzzer and found other similar control flow edge cases. While the compiler never emits negative offsets for jump opcodes like OpJump and OpJumpIfTrue, the VM will happily take those in if defined as program bytecode. I was able to generate an infinite loop quite easily.

Example:

OpInt        // arg:  ...
OpInt        // arg:  ...
OpJump       // arg: -N (negative offset)
OpInt
OpJump

Fix is simple:

func (vm *VM) Run(program *Program, env any) (_ any, err error) {
    ...
	for vm.ip < len(program.Bytecode) {
		case OpJump:
			if arg < 0 {
				panic("negative jump offset is invalid")
			}
			vm.ip += arg
            ...

I can certainly open PRs to fix these, if we find this type of VM hardening feasible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m thinking of what benefits other than more readable error messages those checks can bring

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants